iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
佛心分享-SideProject30

吃出一個SideProject!系列 第 29

Day 29:實作註冊帳號 Email 驗證功能 (2)

  • 分享至 

  • xImage
  •  

今天接續昨天的內容,要繼續實作註冊帳號時的 Email 驗證功能。
昨天我們已經梳理過流程,說明了要修改與新增的內容,並且也完成了前置作業(申請應用程式密碼與引入套件)
現在讓我們著手完成這個功能吧~

前端程式碼新增與調整

我們先到 AuthService 中新增呼叫 /register 與 /verify-email 端點的方法。再到 auth package 底下新增 registerverify-email 元件處理對應的畫面與邏輯。
(因為到目前為止都聚焦在後端的開發,前端頁面只是方便實作某些需要前後端配合的功能,因此針對前端程式碼的處理都會比較簡略,暫且忽略大部分檢核與錯誤處理。)

AuthService

AuthService 中新增呼叫 /register/verify-email 端點的方法:

export class AuthService {
	...
	// 呼叫後端 API 來註冊一個新使用者。
  register(registrationData: RegistrationRequest): Observable<RegistrationResponse> {
    const backendApi = `${environment.apiBaseUrl}/users/register`;
    return this.http.post<RegistrationResponse>(backendApi, registrationData);
  }

	// 呼叫後端 API 來驗證 Email Token。
  verifyEmail(token: string): Observable<VerificationResponse> {
    const backendApi = `${environment.apiBaseUrl}/users/verify-email`;
    return this.http.post<VerificationResponse>(backendApi, { token });
  }
}

關於傳入參數:RegistrationRequest 根據我們目前後端的設計,註冊僅需 email 與密碼;驗證email 則僅需提供 token 給後端進行驗證。

register:註冊頁面元件

透過 Reactive Form 來建立一個簡單的註冊表單。

  • register.html

    註冊頁面包含 email 、密碼欄位與登入按鈕,此處透過 FormGroup 搭配 FormControl 來接收表單輸入值:

    <div class="register-container">
      <h1>註冊頁面</h1>
      <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
        <div class="form-group">
          <label for="email">Email</label>
          <input id="email" type="email" formControlName="email" >
        </div>
        <div class="form-group">
          <label for="password">密碼</label>
          <input id="password" type="password" formControlName="password" >
        </div>
        <button type="submit" [disabled]="registerForm.invalid">註冊</button>
      </form>
    </div>
    
  • register.ts

    若是註冊成功,此處以 Alert 提示使用者需要到信箱啟用帳號,並將用戶導回登入頁:

    export class RegisterComponent {
    
      registerForm: FormGroup;
    
      constructor(
        private fb: FormBuilder,
        private authService: AuthService,
        private router: Router
      ) {
    	  // 建立表單以接收輸入值
        this.registerForm = this.fb.group({
          email: ['', [Validators.required, Validators.email]],
          password: ['', [Validators.required, Validators.minLength(8)]],
        });
      }
    
      get email() {
        return this.registerForm.get('email');
      }
      get password() {
        return this.registerForm.get('password');
      }
    
      onSubmit(): void {
        // 呼叫 AuthService 中的註冊方法
        this.authService.register(this.registerForm.value).subscribe({
          next: () => {
            alert('註冊成功!請檢查您的信箱以啟用帳號。');
            this.router.navigate(['/auth/login']);
          }
        });
      }
    

verify-email:驗證頁面元件

建立一個簡單的驗證頁面如下:

  • verify-email.html

    <div class="container">
      <div *ngIf="status === 'verifying'">
        <p>正在驗證您的 Email,請稍候...</p>
      </div>
    
      <div *ngIf="status === 'success'">
        <h2>帳號啟用成功!</h2>
      </div>
    </div>
    
    
  • verify-email.ts

    這個頁面的元件在初始化時 ,會從 URL 中解析出 token 參數,並向後端的 /verify-email 端點發起請求,並根據請求結果渲染頁面:

    export class VerifyEmailComponent implements OnInit {
    
      status: 'verifying' | 'success'  = 'verifying';
    
      constructor(
        private route: ActivatedRoute,
        private router: Router,
        private authService: AuthService
      ) { }
    
      ngOnInit(): void {
        const token = this.route.snapshot.queryParamMap.get('token');
    
        if (token) {
          this.authService.verifyEmail(token).subscribe({
            next: () => {
              this.status = 'success';
            }
            // TODO:未來根據後端回傳進行錯誤處理,若 token 過期要可以重發驗證信。
          });
        } else {
          this.router.navigate(['/auth/login']);
        }
      }
    }
    

後端程式碼新增與調整

這個章節將依序說明以下實作:

  1. 建立 VerificationToken Entity 及 Repository
  2. 新增Email 服務與寄送方法
  3. 修改註冊時的邏輯
  4. 新增 verify-email 方法
  5. 新增 /verify-email端點。

建立 VerificationToken Entity 與 Repository

  • VerificationToken Entity

    預期 VerificationToken 應包含 token 字串、UserEntity 的關聯 (外鍵),以及一個 expiryDate (過期時間)。

    @Entity
    @Getter
    @Setter
    @NoArgsConstructor
    public class VerificationTokenEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @Column(nullable = false)
        private String token;
    
        // 一個 Token 只對應一個使用者
        @OneToOne(fetch = FetchType.LAZY)
        @JoinColumn(nullable = false, name = "user_id")
        private UserEntity user;
    
        @Column(nullable = false)
        private Instant expiryDate;
    
        public boolean isExpired() {
          return this.expiryDate.isBefore(Instant.now());
        }
    
        public VerificationTokenEntity(String token, UserEntity user, Instant expiryDate) {
          this.token = token;
          this.user = user;
          this.expiryDate = expiryDate;
        }
    
    }
    
  • VerificationTokenRepository

    新增 VerificationTokenEntity 的 Repository 好讓我們可以在資料庫中新增資料,新增 findByToken 方法以供後續驗證 Email 的 API 使用:

    public interface VerificationTokenRepository extends JpaRepository<VerificationTokenEntity, Long> {
        Optional<VerificationTokenEntity> findByToken(String token);
    }
    

新增 Email 寄送服務

建立一個新的 Service,當中處理包含與 Email 相關的服務。

  • EmailServiceImpl

    新增 sendVerificationEmail 方法來實作寄送驗證 Email 的方法:

    @Service
    public class EmailServiceImpl implements EmailService {
    
        private static final Logger logger = LoggerFactory.getLogger(EmailService.class);
    
        @Autowired
        private JavaMailSender mailSender;
    
        @Value("${frontend.base.url}")
        private String frontendBaseUrl;
    
        @Async // 標記此方法為非同步執行
        public void sendVerificationEmail(UserEntity user, String token) {
            try {
    
                // 建立郵件內容
                SimpleMailMessage email = new SimpleMailMessage();
                email.setFrom("Food Print Service<xxxx@gmail.com>"); // 指定寄件人名稱
                email.setTo(user.getEmail());
                email.setSubject(" 【Food Print Service】請驗證您的 Email 以啟用帳號");
                String message = buildVerificationMessage(user, token);
                email.setText(message);
    
                // 寄送郵件
                mailSender.send(email);
    
                logger.info("已成功寄送驗證信至:{}", user.getEmail());
    
            } catch (MailException e) {
    		        // 非正式的錯誤處理
                logger.error("寄送驗證信至 {} 時發生錯誤", user.getEmail(), e);
            }
        }
    
        private String buildVerificationMessage(UserEntity user, String verificationToken) {
    
            String confirmationUrl = frontendBaseUrl + "/auth/verify-email?token=" + verificationToken;
    
            return String.format(
                    "您好,\n\n" +
                            "感謝您註冊 Foot Print Service,請點選下方連結啟用您的帳戶:\n\n" +
                            "%s\n\n" +
                            "若未於本網站註冊,請忽略此信。",
                    confirmationUrl
            );
        }
    }
    
    • 此處的 JavaMailSender 是由 spring-boot-starter-mail 根據 application.properties 自動配置好的 Bean。
    • 由於 Email 寄送非即時,可能會遇到延遲,因此以非同步的方式寄發郵件,不等待寄送郵件完成屆時可直接回應前端成功訊息。除了寄送 Email 外,圖片上傳等非核心業務邏輯的作業也常以非同步的方式執行,讓不需要即時回應的工作於背景執行,避免影響使用者體驗。若此功能非即時,但必須完成,也可以透過使用 queue 來增強可靠性。

修改註冊邏輯

  • UserEntity

    最初我們實作 UserDetails,簡單讓 isEnabled 方法固定回傳 true。現在讓我們加入 enabled 欄位,讓相關服務可以透過 setter 來改變帳號的驗證狀態:

    public class UserEntity implements UserDetails {
    		...
    
        @Column(nullable = false)
        private boolean enabled = false;
    
        @Override
        public boolean isEnabled() { return this.enabled; }
    }
    
  • register

    回到 AuthService,在先前開發的 register 方法中新增兩個環節:
    (1) 新建用戶時將驗證狀態設為停用
    (2) 建立驗證 Token ,並加入寄發驗證連結。

    @Transactional
      public UserEntity registerUser(RegistrationRequest registrationRequest){
    
          if (userRepository.findByEmail(registrationRequest.email()).isPresent()) {
              throw new IllegalStateException("該信箱已被註冊。");
          }
    
          UserEntity user = new UserEntity();
          user.setEmail(registrationRequest.email());
          user.setPassword(passwordEncoder.encode(registrationRequest.password()));
    
          // (1)建立用戶時將驗證狀態設為停用
          user.setEnabled(false);
    
          UserEntity savedUser = userRepository.save(user);
    
          // (2)建立驗證 Token 並呼叫非同步方法寄送驗證 Email
          String token = UUID.randomUUID().toString();
          VerificationTokenEntity verificationToken = new VerificationTokenEntity(token, user, Instant.now().plusMillis(refreshTokenExpirationMs));
          verificationTokenRepository.save(verificationToken);
    
          emailService.sendVerificationEmail(user, token);
    
          return savedUser;
      }
    
    

新增驗證信箱的方法與對應 API 端點

  • verifyEmail

    AuthService 中新增 verifyEmail 的方法驗證 token 有效性:

    public void verifyEmail(String token) {
    
        // 1. findByToken
        VerificationTokenEntity verificationToken = verificationTokenRepository.findByToken(token)
            .orElseThrow(() -> new TokenNotFoundException("無效的驗證 Token"));
    
        // 2. 檢查 Token 是否過期
        if (verificationToken.isExpired()) {
            verificationTokenRepository.delete(verificationToken); 
            throw new TokenExpiredException("此驗證 Token 已過期");
        }
    
        // 3. 使用者狀態設定為啟用
        UserEntity user = verificationToken.getUser();
        user.setEnabled(true);
        userRepository.save(user);
    
        // 4. 刪除已使用的 Token
        verificationTokenRepository.delete(verificationToken);
    }
    

    此處自訂了 Exception,並在 GlobalExceptionHandler 定義例外處理,前面實作有過類似的說明就不附上內容了。

  • AuthController

    我們開立 /verify-email 端點提供前端驗證 Eaill 使用:

    public class AuthController {
    		...
        @PostMapping("/verify-email")
        public ResponseEntity<?> verifyEmail(@Valid @RequestBody VerificationRequest verificationRequest) {
            authService.verifyEmail(verificationRequest.token());
            return ResponseEntity.ok().build();
        }
    }
    

功能測試

在此簡單附上功能測試成功的幾個主要畫面。

  1. 於註冊頁面註冊成功後,提醒用戶至信箱啟用帳戶。
    https://ithelp.ithome.com.tw/upload/images/20251013/20178099LUwUWB8NaS.png

  2. 至信箱查看寄發之驗證信內容。
    https://ithelp.ithome.com.tw/upload/images/20251013/20178099R2rVqxmm44.png

  3. 點擊信箱內的驗證連結,導回驗證頁面並顯示驗證結果。
    https://ithelp.ithome.com.tw/upload/images/20251013/201780995dnCpis2QI.png


透過今天簡單的實作,我們完成了在建立帳號時寄送驗證 Email 的服務。

不過既然我們設計 token 都有考量到過期的狀況,就表示應該有個重發驗證信的機制,今天的內容本來有包含重新寄發驗證信的功能,但考量到篇幅與對應的處理會使整篇文章更冗贅,還是先行移除了,著重在實作 Email 驗證功能上。


上一篇
Day 28:實作註冊帳號 Email 驗證功能 (1)
系列文
吃出一個SideProject!29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言